Skip to content

feat(client): per-request envelope auto-emission and probe completion#2320

Merged
felixweinberger merged 8 commits into
v2-2026-07-28from
fweinberger/envelope-auto-emit
Jun 18, 2026
Merged

feat(client): per-request envelope auto-emission and probe completion#2320
felixweinberger merged 8 commits into
v2-2026-07-28from
fweinberger/envelope-auto-emit

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

On a connection that negotiated a 2026-07-28+ protocol revision (auto-negotiated or pinned), the client now automatically attaches the per-request _meta envelope — the reserved protocol-version / client-info / client-capabilities keys — to every outgoing request and notification, not just the connect-time server/discover probe. User-supplied _meta keys take precedence; the auto-attached client-capabilities reflect what the client actually registered. Legacy-era connections (the default, and the 'auto'-mode fallback) never gain these keys, so 2025-era outbound traffic is byte-identical to before.

Also adds Client.getProtocolEra() ('legacy' | 'modern' | undefined), the ProtocolEra type, Client.setVersionNegotiation() for configuring negotiation pre-connect on an already-constructed instance, and the probe.maxRetries knob (default 0, governs probe-timeout re-sends only — the spec-mandated -32004 corrective continuation is never counted against it). The versionNegotiation default remains 'legacy'.

…a connections

On a connection that negotiated a 2026-07-28+ protocol revision (auto-negotiated
or pinned), the client now automatically attaches the per-request `_meta`
envelope — the reserved protocol-version / client-info / client-capabilities
keys — to every outgoing request and notification, not just the connect-time
`server/discover` probe. User-supplied `_meta` keys take precedence over the
auto-attached ones. The auto-attached client-capabilities reflect what the
client actually registered (sampling/elicitation/roots).

Legacy-era connections never gain these keys: the seam returns `undefined` and
outbound traffic is byte-identical to a 2025 client, so the `'auto'`-mode
fallback and the plain legacy connect stay byte-untouched.

Adds a small protected `Protocol._outboundMetaEnvelope()` seam (base: no-op)
applied at the request, notification, and cancellation send sites. Adds the
`ProtocolEra` type (`'legacy' | 'modern'`), `Client.getProtocolEra()`, and
`Client.setVersionNegotiation()` for configuring negotiation pre-connect on an
already-constructed instance.
Pins the final `probe: { timeoutMs?, maxRetries? }` member names. `maxRetries`
(default `0`) governs timeout re-sends only: the probe is re-sent after a
timeout up to `maxRetries` times before the transport-aware timeout verdict
applies. The spec-mandated `-32004` corrective continuation
(select-and-continue with a mutual modern version) is a separate negotiation
step and is never counted against `maxRetries`.
The client now attaches the per-request `_meta` envelope itself once a modern
era is negotiated, and exposes `setVersionNegotiation()` for pre-connect
configuration — so the entryModern arm no longer needs the
`attachModernEnvelope` transport wrap or the `pinModernNegotiation`
private-field write. The arm pins the scenario's client to 2026-07-28 via the
public setter and connects.

The 130 entryModern matrix cells stay green with both shims deleted.

One scenario assertion relaxed (`protocol:error:invalid-params`): the recorded
outbound `tools/call` params now carry the auto-attached `_meta` envelope on
the entryModern arm, which is additive and not part of the assertion's intent
(the body still proves the malformed request reached the wire and `name` is
absent).
…est envelope

Document explicitly that the v2 default of `versionNegotiation` is `'legacy'`
(absent ⇒ the plain 2025 connect sequence, byte-identical to v1.x) and show how
to opt into `'auto'` and pin, including what happens against a 2025-only
server. Record the per-request `_meta` envelope auto-emission, the new
`getProtocolEra()` / `setVersionNegotiation()` accessors, and the
`probe.maxRetries` knob with its `-32004` disambiguation sentence. Adds a
short version-negotiation section to the client guide and a changeset for the
new public surface.
@felixweinberger felixweinberger requested a review from a team as a code owner June 18, 2026 14:35
@changeset-bot

changeset-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2451b1a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/core Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2320

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2320

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2320

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2320

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2320

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2320

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2320

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2320

commit: 2451b1a

…w the client auto-attaches it

The client now attaches the per-request `_meta` envelope itself on
modern-era connections, so the example clients and the conformance
fixture no longer need to build and pass it by hand.

- examples/client/src/multiRoundTripClient.ts: remove the
  `envelope()` helper and the META_KEY imports; the auto-fulfilment
  leg is a plain `client.callTool()`; the manual leg keeps
  `client.request()` (its `allowInputRequired: true` return is the
  union, which `callTool()`s typed signature does not surface) but
  drops the manual `_meta`. Stop-gap header comment removed.
- examples/client/src/dualEraStdioClient.ts: modern leg is a plain
  `client.callTool()`; envelope literal, META_KEY imports, and the
  stop-gap header comment removed.
- test/conformance/src/everythingClient.ts: remove `modernEnvelope()`
  and the META_KEY imports; `runToolsCallModernClient` uses
  `listTools()` / `callTool()`; the four `sep-2322` callTool sites
  drop the explicit `_meta`. The local `server/discover` response shim
  stays (separate upstream gap).
Comment thread .changeset/envelope-auto-emission.md
Comment thread docs/client.md
Comment thread test/e2e/helpers/index.ts
…low-up" wording and anchor the negotiation snippet

- examples/server/src/dualEraStreamableHttp.ts: header comment now states the
  client attaches the per-request `_meta` envelope itself once a modern era
  is negotiated.
- test/integration/test/server/createMcpHandler.test.ts: the two typed
  tools/call round trips through an auto-negotiating Client use plain
  `client.callTool()` (no manual `_meta`); the `modernEnvelope()` helper is
  gone and the raw-fetch unsupported-revision negative test builds its
  envelope inline.
- docs/client.md + examples/client/src/clientGuide.examples.ts: the
  "Protocol version negotiation" snippet is now sourced from a type-checked
  `Client_versionNegotiation` region.
…ly scenarios

The entryModern arm unconditionally pins the scenario client to 2026-07-28
via `setVersionNegotiation({mode:{pin:...}})` before connect(), so any
`versionNegotiation: {mode: 'auto'}` passed at construction time is
overridden and never observed. Drop it from the scenarios that only run
on entry arms (mrtr ×4, hosting-entry-streaming, hosting-entry-stamping,
hosting-entry dual-era-one-factory) and update the now-stale
"auto-negotiating" wording in comments. The dual-era-one-factory
discover assertion is satisfied by pin mode (pin discovers too), so its
ternary collapses to a single plain client.

Also document the unconditional pin in test/e2e/CLAUDE.md so scenarios
that need to assert non-pin negotiation behavior know to restrict off
entryModern.
Comment thread .changeset/envelope-auto-emission.md
… calls in dual-era stdio tests

The auto-negotiating Client now attaches the per-request envelope itself
on modern-era connections, so the hand-built `_meta` on `client.request`/
`client.callTool` was dead code that bypassed the auto-emission path the
test exercises. Raw `rawRequest`/`transport.send` bodies (which never go
through Client) keep building the envelope by hand.

Also drops the stale "stop-gap until automatic envelope emission lands
client-side" comment and the now-unused meta-key imports.
@felixweinberger felixweinberger merged commit 7f425ee into v2-2026-07-28 Jun 18, 2026
14 checks passed
@felixweinberger felixweinberger deleted the fweinberger/envelope-auto-emit branch June 18, 2026 16:23
Comment on lines 35 to +38
// Both cells host the same handler shape — one ctx-taking factory, the
// 'stateless' legacy posture — and differ only in the client driving it.
const client =
transport === 'entryModern'
? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } })
: new Client({ name: 'plain-2025-client', version: '1.0.0' });
// 'stateless' legacy posture — driven by a plain client; the entry arm
// decides which era serves it (entryModern pins the client to 2026-07-28).
const client = new Client({ name: 'dual-era-client', version: '1.0.0' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The 'typescript:hosting:entry:dual-era-one-factory' requirement in test/e2e/requirements.ts:2280-2285 still says the cell proves "an auto-negotiating client reaches 2026-07-28 via server/discover", but this PR rewrote the cell to use a plain Client that the entryModern arm pins via setVersionNegotiation({ mode: { pin: '2026-07-28' } }), so the cell now exercises pin-mode negotiation and semantically duplicates the separate 'typescript:hosting:entry:pin-negotiation' requirement. Update the manifest's behavior/note wording to match the arm-pinned client (or restore auto-mode coverage on a non-entry transport so the described behavior is still proven somewhere e2e).

Extended reasoning...

What drifted. This PR changes the modern leg of the typescript:hosting:entry:dual-era-one-factory cell (test/e2e/scenarios/hosting-entry.test.ts:35-38): the scenario no longer constructs an auto-negotiating client (versionNegotiation: { mode: 'auto' }); it builds a plain Client and relies on the entryModern arm's unconditional client.setVersionNegotiation({ mode: { pin: '2026-07-28' } }) in test/e2e/helpers/index.ts. test/e2e/CLAUDE.md was updated to document the unconditional pin ("a scenario that needs to assert non-pin negotiation behavior (e.g. mode: 'auto' probing) must restrict off entryModern"), but the requirement manifest entry was not: test/e2e/requirements.ts:2283 still reads "...an auto-negotiating client reaches 2026-07-28 via server/discover (never initialize)...".\n\nWhy it matters. Per the suite's own conventions, requirements.ts is the pure-data source of truth for what each cell proves; reviewers and the coverage gates rely on its behavior wording. The coverage gate only checks that the requirement id is cited by a verifies() call, not that the prose matches the test body, so this drift is silent. After this PR the cell exercises pin-mode negotiation on the entryModern arm — the same path already covered by typescript:hosting:entry:pin-negotiation (requirements.ts:2287-2292) — so the manifest both misdescribes the cell and masks the fact that no e2e cell now exercises 'auto'-mode negotiation against the createMcpHandler entry (only the integration suite covers auto over real HTTP, e.g. test/integration/test/server/createMcpHandler.test.ts).\n\nStep-by-step. (1) On the entryModern arm, the cell builds new Client({ name: 'dual-era-client', ... }) with no versionNegotiation. (2) wire('entryModern', ...) calls client.setVersionNegotiation({ mode: { pin: '2026-07-28' } }) before connect(). (3) connect() resolves the pin plan in resolveVersionNegotiation() — the auto probe-and-fallback state machine never runs. (4) The cell's wire assertions ("server/discover sent, never initialize") still pass, because pin mode also probes via server/discover, so nothing in CI flags that the behavior described at requirements.ts:2283 ("an auto-negotiating client...") is no longer what the cell does.\n\nWhy nothing else catches it. The PR's own follow-up commits (3a3cc3e, 3ac89ea) deliberately dropped the now-dead mode: 'auto' configs from the entryModern-only scenarios, and the existing review comment on helpers/index.ts:173 covers the unconditional pin overriding scenario clients — but neither touches the stale requirements.ts behavior text, which is a distinct, still-unaddressed item.\n\nHow to fix. Either (a) update the requirement's behavior/note wording at requirements.ts:2280-2285 to say the entryModern leg is driven by an arm-pinned client reaching 2026-07-28 via server/discover, or (b) if auto-mode coverage against the entry is meant to be preserved, restore it explicitly (e.g. a cell on a non-entry transport, or have the scenario pass its negotiation mode through wire()). Option (a) is a one-line wording change and matches what the test now actually proves.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant